16 — 鬼宅

Three.js Journey · Bruno Simon · 难度:简单

16 — 鬼宅 课程海报

介绍

这节课,我们要把目前为止学到的东西付诸实践,开始享受构建一个更真实项目带来的乐趣——创建一个鬼宅:

lesson demo video preview

我们不会加载预制模型,只依赖基础几何体(primitive shapes)。这有局限性,但尽可能熟练掌握这些基础几何体很重要。

至于纹理,我们会使用 Poly Haven。其实所有的纹理都要你自己选,我负责讲解如何以正确的格式下载、如何正确地应用它们,以及如何在不伤害质量的前提下优化它们。

一路走来,我们会发现一些技巧,让场景看起来更好,比如雾(fog)、逼真的天空、好看的阴影、位移贴图等。

设置

starter 已经包含以下内容:

Timer

关于 Timer 类的一点点说明:它是我们到目前为止一直在用的 Clock 的替代品。它非常相似,但有以下几个关键区别:

测量单位的小建议

我们刚用基础几何体创建东西时总是会犯一个新手错误,就是随便使用测量单位。Three.js 里的一个单位可以是你想要的任何东西。

想象你在创建一片巨大的、要飞越的景观。那样的话,你可能会把一个单位看作一公里。如果你在搭房子,你可能把一个单位看作一米,如果你在做弹珠游戏,你可能把一个单位看作一厘米。

有一个明确的单位比例会帮你创建几何体。假设你想做门。你知道门比你稍微高一点,所以它应该大约有 2 米。

对于使用英制单位的人来说,你得自己做换算。

地面

在移除占位球体之前,我们先来添加地面。

它将是一个完美居中在场景里的正方形平面。这样,当我们摆放房子各个部件时,就可以把 y(纵轴)上的 0 当作地面高度。

在占位球体那段之后,用一个 Mesh 创建 floor 变量,在它里面实例化一个 PlaneGeometry 和一个 MeshStandardMaterial,然后把它加到 scene:

// Floor
const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial()
)
scene.add(floor)

我们需要旋转它,但在此之前,先说说我们刚才做了什么。

首先,我们直接在 Mesh 里实例化了 PlaneGeometry 和 MeshStandardMaterial。这让代码比用专门变量稍微短一点。

如果需要,我们仍然可以通过 floor.geometry 和 floor.material 访问它们。

其次,我们把平面的大小用 20 和 20,这应该足够容纳我们的房子和坟墓,但如果我们能注意到边缘,这样的正方形看起来不会好看。幸运的是,这节课后面我们会施展一个小技巧,把边缘淡出。

第三,我们用了 MeshStandardMaterial,因为我们要追求真实的效果。我们要用的纹理是 PBR 纹理,PBR 代表“基于物理的渲染(Physically Based Rendering)”。这些会让我们的材质看起来真实而可信。

让我们旋转这个平面。现在是个好机会,让你自己尝试猜一下轴和旋转角度。

把 rotation.x 设为 - Math.PI * 0.5(相当于逆时针旋转四分之一圆):

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial()
)
floor.rotation.x = - Math.PI * 0.5
scene.add(floor)

稍后我们会添加一些纹理,但现在先让我们用这样的中性材质来创建各个部件。

移除占位球体:

房子

Group

与其把构成即将到来的房子的每个物体都放进 scene,不如创建一个 Group,以防我们想整体移动或缩放。

在Floor部分之后,创建一个House container部分,实例化一个 house Group 并把它加到 scene:

// House container
const house = new THREE.Group()
scene.add(house)

从现在起,记住要把房子组成部分(墙壁、屋顶、门等)加进 house,而不是 scene。

墙壁

我们的房子会比较小。既然我们把 1 个 Three.js 单位看作 1 米,我们就把它的尺寸设为 4, 2.5, 4:

在house Group之后,创建 walls(有复数 s)Mesh,在它里面实例化一个 BoxGeometry 和一个 MeshStandardMaterial。然后把它加到 house(不是 scene):

// Walls
const walls = new THREE.Mesh(
    new THREE.BoxGeometry(4, 2.5, 4),
    new THREE.MeshStandardMaterial()
)
house.add(walls)

墙壁有一半埋在了地面里,因为几何体的原点在其中心。

用 position.y 属性把它们向上移动 1.25(高度的一半):

const walls = new THREE.Mesh(
    // ...
)
walls.position.y += 1.25

墙壁就到这里。显然,你可以提升建筑复杂度,但我们现在先保持简单。

注意,我们会在各个地方放一堆数值,用来定位和旋转物体。一个好的做法是把这些值放进单独的变量,并用那些变量作为所有测量的参考。这样做会让更新房子容易得多,但对我们这个练习来说有点小题大做,所以我们就这么做了。

屋顶

对于屋顶,我们想要一个金字塔。看看 Three.js 文档,试着找找这样的基础几何体。

如你所见,没有"金字塔"几何体。但也许你能找到替代方案。

我们要用一个 ConeGeometry,并把它的边数设为只有 4。ConeGeometry 的前三个参数是 radius(半径)、height(高度)和 radialSegments(侧面数量)。

创建 roof Mesh,按如下方式实例化一个 ConeGeometry 和一个 MeshStandardMaterial,然后把它加到 house:

// Roof
const roof = new THREE.Mesh(
    new THREE.ConeGeometry(3.5, 1.5, 4),
    new THREE.MeshStandardMaterial()
)
house.add(roof)
lesson demo video preview

看不到它?因为它藏在墙壁里面。

把 position.y 属性设为 2.5 + 0.75 来把它抬起来:

const roof = new THREE.Mesh(
    // ...
)
roof.position.y = 2.5 + 0.75

你可能会问为什么要 2.5 + 0.75?2.5 是墙壁的高度,0.75 是屋顶高度的一半,因为圆锥的原点在它的中心:

我们现在需要旋转它。再次尝试找出轴和角度。

轴是 y(垂直轴),角度是 Math.PI * 0.25,也就是圆周的 1/8:

const roof = new THREE.Mesh(
    // ...
)
roof.position.y = 2.5 + 0.75
roof.rotation.y = Math.PI * 0.25

门

虽然听起来有点怪,但我们要用一个简单的正方形平面当门。剧透警告,这是因为我们要使用之前课程中用过的漂亮木门纹理。

创建 door Mesh,按如下方式实例化一个 PlaneGeometry 和一个 MeshStandardMaterial,然后把它加到 house:

// Door
const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2),
    new THREE.MeshStandardMaterial()
)
house.add(door)
lesson demo video preview

看不到它?又是同样的原因,它在墙壁里面。

按下述方式在 y 和 z 轴上移动它们:

const door = new THREE.Mesh(
    // ...
)
door.position.y = 1
door.position.z = 2
house.add(door)

不幸的是,因为门的颜色和墙壁一样,我们看不见它了。

你可能会觉得这不是问题,等我们加了纹理就能看到门了。不幸的是,我们犯了个错误,而看不到门就让我们注意不到那个错误。

这就是为什么我们要把门的颜色改成 'red':

const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2),
    new THREE.MeshStandardMaterial({ color: 'red' })
)
lesson demo video preview

现在我们能看到点什么了,但到底发生了什么?

那个闪烁的画面叫做"z-fighting"(深度冲突)。它发生在两个面在数学上处于完全相同的位置,GPU 不知道哪个在前面、哪个在后面的时候。

解决这个问题最简单的办法,是把物体稍微挪开一点:

door.position.z = 2 + 0.01

有点蠢,但很有效。

让我们移除 color: 'red':

const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2),
    new THREE.MeshStandardMaterial()
)

灌木丛

我们要用简单的球体在门两侧放两丛灌木。

现在是应用优化技巧的绝佳时机。我们要用 4 个不同的 Mesh 创建 4 丛灌木,但它们全都共享同一个几何体和同一个材质。

用 SphereGeometry 和 MeshStandardMaterial 创建下面的 bushGeometry 和 bushMaterial(单数):

// Bushes
const bushGeometry = new THREE.SphereGeometry(1, 16, 16)
const bushMaterial = new THREE.MeshStandardMaterial()

现在创建下面 4 个 Mesh。为了节省你的时间,我会给出它们的 position 和 scale:

const bush1 = new THREE.Mesh(bushGeometry, bushMaterial)
bush1.scale.set(0.5, 0.5, 0.5)
bush1.position.set(0.8, 0.2, 2.2)

const bush2 = new THREE.Mesh(bushGeometry, bushMaterial)
bush2.scale.set(0.25, 0.25, 0.25)
bush2.position.set(1.4, 0.1, 2.1)

const bush3 = new THREE.Mesh(bushGeometry, bushMaterial)
bush3.scale.set(0.4, 0.4, 0.4)
bush3.position.set(- 0.8, 0.1, 2.2)

const bush4 = new THREE.Mesh(bushGeometry, bushMaterial)
bush4.scale.set(0.15, 0.15, 0.15)
bush4.position.set(- 1, 0.05, 2.6)

屏幕上还是什么都没有?因为我们还没有把它们加进 house。现在正好可以向你展示,你可以在 add() 里传入多个物体:

house.add(bush1, bush2, bush3, bush4)

它们是不是很可爱?好吧,可爱不了多久。

房子本身就这样了。

坟墓

我们要添加一堆坟墓。它们要围在房子周围,但不属于墙壁的一部分。

和灌木一样,我们只创建一份几何体和一份材质,供每个坟墓复用。

用 BoxGeometry 创建 graveGeometry,用 MeshStandardMaterial 创建 graveMaterial:

// Graves
const graveGeometry = new THREE.BoxGeometry(0.6, 0.8, 0.2)
const graveMaterial = new THREE.MeshStandardMaterial()

在创建坟墓 Mesh 之前,我们要先创建一个 Group。这不是必须的,但如果你想一次性移动或缩放所有坟墓,它被认为是好的实践,就像我们对 house 做的那样。

创建 graves Group 并把它加到 scene(不是 house):

const graves = new THREE.Group()
scene.add(graves)

我们要 30 个坟墓。让我们先加一个从 0 到 30(不含)的经典 for() 循环:

for(let i = 0; i < 30; i++)
{
}

在 for() 循环里,用 Mesh、graveGeometry 和 graveMaterial 创建坟墓:

for(let i = 0; i < 30; i++)
{
    // Mesh
    const grave = new THREE.Mesh(graveGeometry, graveMaterial)

    // Add to the graves group
    graves.add(grave)
}
lesson demo video preview

这 30 个坟墓目前都挤在房子里的同一个位置。

我们现在要把它们摆到房子四周。更准确地说,我们要把她们放在一个半径各异的圆上:

这时候三角学就派上用场了。

首先,我们要定义 0 到一整圈之间随机的角度。

使用三角学时,我们需要用弧度,其中 Math.PI 约等于 3.1415,相当于半圈。既然我们想要一整圈,就需要把 Math.PI 乘以 2。

让我们创建一个 angle 变量,用 Math.random() 乘以 Math.PI 再乘以 2:

for(let i = 0; i < 30; i++)
{
    const angle = Math.random() * Math.PI * 2
    
    // ...
}

在三角学里,当你把同一个角度同时传给正弦和余弦,你会得到圆周上一个定位点的 x 和 y 坐标。

最直观的感受方式,是 Wikipedia 的 Sine and Cosine 页面上的这个动画:

在 JavaScript 里,对于正弦和余弦,我们可以用 Math.sin() 和 Math.cos()。把 angle 传给它们,把结果保存为 x 和 z:

for(let i = 0; i < 30; i++)
{
    // Coordinates
    const angle = Math.random() * Math.PI * 2
    const x = Math.sin(angle)
    const z = Math.cos(angle)

    // ...
}

是的,我们把第二个命名为 z 而不是 y,尽管我前面提到过 x 和 y。x 和 y 只是 2D 空间里轴的通用名称,但我们的情况是要让坟墓沿着 x 和 z 轴移动。

是时候用 position.x 和 position.z 根据这些坐标移动 grave 了:

for(let i = 0; i < 30; i++)
{
    // ...
    
    // Mesh
    const grave = new THREE.Mesh(graveGeometry, graveMaterial)
    grave.position.x = x
    grave.position.z = z

    // ...
}
lesson demo video preview

好的,这是一个开始,但圆太小了。

要修复这个,创建一个 radius 变量并设为 4,把 Math.sin(…) 和 Math.cos(…) 乘以这个半径:

for(let i = 0; i < 30; i++)
{
    // Coordinates
    const angle = Math.random() * Math.PI * 2
    const radius = 4
    const x = Math.sin(angle) * radius
    const z = Math.cos(angle) * radius

    // ...
}

不错,但这个圆太规则了。让我们把 radius 设为 3 加上一个用 Math.random() 产生的随机值,加点随机性:

for(let i = 0; i < 30; i++)
{
    const angle = Math.random() * Math.PI * 2
    const radius = 3 + Math.random() * 4
    const x = Math.sin(angle) * radius
    const z = Math.cos(angle) * radius

    // Mesh
    const grave = new THREE.Mesh(graveGeometry, graveMaterial)
    grave.position.x = x
    grave.position.z = z

    // Add to the graves group
    graves.add(grave)
}

定位很好了,但坟墓有一半埋在地面里,并且朝向同一个整齐的方向。

把 position.y 设为 Math.random() * 0.4:

for(let i = 0; i < 30; i++)
{
    // ...
    grave.position.x = x
    grave.position.y = Math.random() * 0.4
    grave.position.z = z

    // ...
}

现在,对于旋转,我们要给全部三个轴都添加随机性,但我们要让旋转同样有可能是正的,也有可能是负的。

既然 Math.random() 返回 0 到 1 之间的值,我们要减去 0.5,让它从 -0.5 到 +0.5(正负各半)。

for(let i = 0; i < 30; i++)
{
    // ...

    grave.rotation.x = Math.random() - 0.5
    grave.rotation.y = Math.random() - 0.5
    grave.rotation.z = Math.random() - 0.5

    // ...
}

这个效果有点太强了。让我们把 Math.random() - 0.5 放在括号里并乘以 0.4,来减弱它:

for(let i = 0; i < 30; i++)
{
    // ...

    grave.rotation.x = (Math.random() - 0.5) * 0.4
    grave.rotation.y = (Math.random() - 0.5) * 0.4
    grave.rotation.z = (Math.random() - 0.5) * 0.4

    // ...
}

纹理

没错。我们的 mesh 准备好了。是时候添加一些纹理了。

查找和添加纹理

设置纹理是个漫长的过程。你需要找一个提供优质纹理的好地方。你需要确保自己有权使用它们。你需要下载并优化它们。你需要用不同的方式把它们应用到物体上,这取决于纹理的映射方式。等等。

别担心,我们会一起完成的。

找纹理总是很难,而网页搜索并不总能突出最好的地方。这就是为什么我创建了一个 Notion 页面,汇集了我最喜欢的一些素材库:https://www.notion.so/brunosimon/Assets-953f65558015455eb65d38a7a5db7171?pvs=4

这节课,我们会使用 Poly Haven 的纹理。它们真实、易于下载、你可以更改要下载的内容、免费,而且采用 CC0 许可(你可以随意使用它们)。

如果你欣赏他们的工作,可以通过他们的 Patreon 支持他们。

Poly Haven 也提供模型和环境贴图(我们会在课程后面用到)。

我会向你展示如何从 Poly Haven 设置并下载纹理,但如果你赶时间,可以用视频播放器下方的Resources按钮下载最终版本。

我必须提醒你,我们还会犯错。处理文件名、路径、各种变量及其设置相当麻烦。别担心,慢慢来,你会搞定的。

最后,Poly Haven 可能在写下这节课程之后已经变了。注意潜在的差异。

地面

让我们从地面开始,它实际上是最棘手的一个。等你把它搞定,房子的其他部分你都能应付了。

Alpha 贴图

在从 Poly Haven 选纹理之前,我们还要先处理之前提到的边缘淡出问题。

如果你看一下 static/floor/ 文件夹,你会发现一个 alpha.jpg 文件:

这张图片是个简单的径向渐变,我们要用它来控制地面的 alpha。白色部分会不透明,黑色部分会透明。

在你的 JavaScript 里,紧挨着House部分之前,创建一个Textures部分:

/**
 * Textures
 */

我们要在这里加载所有纹理并设置它们。

首先,我们需要课程前面看到过的 TextureLoader。

实例化 TextureLoader 并把它赋值给 textureLoader 变量:

/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()

记住,一个 TextureLoader 就能处理一个项目的所有纹理。

接下来,用 load() 方法加载 alpha.jpg 文件,并把它保存为 floorAlphaTexture:

const textureLoader = new THREE.TextureLoader()

// Floor
const floorAlphaTexture = textureLoader.load('./floor/alpha.jpg')

记住,从 static/ 文件夹加载素材时,不要写 static/。

从现在起,我们会让每个纹理变量都以 …Texture 结尾。这不是必须的,而且让变量名变得很长,但我认为它更有意义,并且能防止和其余代码产生潜在冲突。如果你不喜欢,可以不用写 …Texture。

给 floor 的 MeshStandardMaterial 传一个对象,把 alphaMap 属性设为 floorAlphaTexture:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        alphaMap: floorAlphaTexture
    })
)

你能猜猜为什么不起作用吗?

当处理 alpha 时,我们需要把 transparent 属性设为 true,告知 Three.js 这个材质现在支持透明:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        alphaMap: floorAlphaTexture,
        transparent: true
    })
)
lesson demo video preview

地面正确地淡出了。

至于如何制作这样的渐变图片,就看你自己了。你可以用 Photoshop、GIMP、Figma,或任何能生成径向渐变的软件。这里我用的是 Figma。

选择和下载纹理

现在,进入有趣的环节。让我们选择纹理、下载它们并应用到地面上。

接下来的部分,我们会选择一些非常具体的纹理,但要开心点!选任何适合你的纹理。下载不同的试试,直到满意为止。

去 Poly Haven 的纹理区,找一个好看的地面纹理。

我个人会使用 Coast Sand Rock 02:

在点击Download按钮之前,我们需要改一些设置,这就是我喜爱 Poly Haven 的原因。你可以精确选择要导出什么。

首先,对于分辨率,把它从 4K 改成 1K:

分辨率旁边是一个控制要导出什么的按钮。选择 ZIP:

接下来,点击菜单图标(如果没有自动打开的话),来控制 ZIP 的内容:

在这里,你可以精确选择要导出什么。这些名字应该让你想起我们在前面课程中见过的东西:

虽然 Coast Sand Rock 02 不是这种情况,但你可能会看到更多选项,比如:

你可能还注意到有一个 AO/Rough/Metal 选项。这个会把这三张贴图合并成一张,分别存在不同的通道里(红、绿、蓝),这对我们来说很完美,因为有更少的纹理更有利于性能(后面会更多提到)。

现在,对于格式,我们有三种选择:

通常,我们对大多数纹理使用 JPG,对 Normal 使用 PNG,因为我们避免对法线贴图进行有损压缩,以防止视觉伪影。但既然我们要用磨损感的纹理,这些伪影不会被看出来,所以我们全部选择 JPG。

是的,还有其他格式。课程后面会更多提到。

给 AO/Rough/Metal、Diffuse、Displacement、Normal 都设置 JPG,并取消勾选任何其他选项:

点击Download,解压文件,用最初的 zip 文件名重命名文件夹,我的情况是 coast_sand_rocks_02_1k。

改名字是可选的,但我觉得在处理不同版本时它很有帮助。

把文件夹放进 static/floor/ 文件夹。

回到 JavaScript,紧跟在 floorAlphaTexture 之后,加载下载的 4 张贴图,并按如下方式命名变量:

// Floor
const floorAlphaTexture = textureLoader.load('./floor/alpha.jpg')
const floorColorTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_diff_1k.jpg')
const floorARMTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_arm_1k.jpg')
const floorNormalTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_nor_gl_1k.jpg')
const floorDisplacementTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_disp_1k.jpg')

这就是你可能会出错的地方,尤其是控制台不一定会因为路径错误而报错。不过,还是看看控制台和 DevTools 里的 Network 标签页,确认一切正常。

lesson demo video preview

我们现在可以把这些纹理应用到 floor 的 MeshStandardMaterial 上了。让我们先用 floorColorTexture 设置 map:

const floor = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        alphaMap: floorAlphaTexture,
        transparent: true,
        map: floorColorTexture
    })
)

有好几个问题。

首先,纹理太大了。要修复这个,我们要用纹理的 repeat 属性,但我们需要对全部 4 张贴图都这么做。repeat 是一个 Vector2,控制同一表面上纹理要重复多少次。值越大,重复图案就越小。

因为它是 Vector2,我们可以用 set() 方法。把它设为 8 和 8:

floorColorTexture.repeat.set(8, 8)
floorARMTexture.repeat.set(8, 8)
floorNormalTexture.repeat.set(8, 8)
floorDisplacementTexture.repeat.set(8, 8)
lesson demo video preview

不是预期的结果,你可能会注意到地面边缘有奇怪的线条。这是因为纹理没有重复。你看到的是最后那些像素被拉伸到了尽头。

要修复这个,我们需要把 wrapS 和 wrapT 设为 THREE.RepeatWrapping,告知 Three.js 这些纹理需要重复:

floorColorTexture.wrapS = THREE.RepeatWrapping
floorARMTexture.wrapS = THREE.RepeatWrapping
floorNormalTexture.wrapS = THREE.RepeatWrapping
floorDisplacementTexture.wrapS = THREE.RepeatWrapping

floorColorTexture.wrapT = THREE.RepeatWrapping
floorARMTexture.wrapT = THREE.RepeatWrapping
floorNormalTexture.wrapT = THREE.RepeatWrapping
floorDisplacementTexture.wrapT = THREE.RepeatWrapping

下一个问题是颜色看起来怪异地发灰。这是因为色彩空间(color space)。

颜色纹理(不是法线贴图、AO 贴图、roughness 贴图等数据纹理)会用 sRGB 编码,以根据人眼感知颜色的方式来优化颜色的存储。

coast_sand_rocks_02_diff_1k.jpg 已经用 sRGB 色彩空间正确编码了,我们需要通过把纹理的 colorSpace 设为 THREE.SRGBColorSpace 来告知 Three.js:

floorColorTexture.colorSpace = THREE.SRGBColorSpace

现在添加 floorARMTexture。因为这一张包含三张贴图,我们可以把它用在 aoMap、roughnessMap 和 metalnessMap 属性上:

const floor = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        // ...
        aoMap: floorARMTexture,
        roughnessMap: floorARMTexture,
        metalnessMap: floorARMTexture
    })
)
lesson demo video preview

这个在这个例子里没有带来巨大差别,但它有助于提升真实感。

让我们继续,把 floorNormalTexture 加到 normalMap:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        // ...
        normalMap: floorNormalTexture
    })
)

这才像话。但还没完。下载纹理时我们还选了 Displacement。

把 floorDisplacementTexture 加到 displacementMap:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        // ...
        displacementMap: floorDisplacementTexture
    })
)

看起来整个纹理往上抬了。如果你记得前面课程的内容,位移贴图会移动实际的顶点。它不像法线贴图那样只是假装。我们通常用位移贴图创建粗糙的轮廓,用法线贴图创建更细微的细节。

换句话说,我们需要那个平面有更多顶点。

把 PlaneGeometry 的 widthSegments 和 heightSegments 参数设为 100:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20, 100, 100),
    // ...
)

好多了,但看起来有点棱角。你可能想添加更多细分,但别。太多顶点对性能不利,尤其是在使用 MeshStandardMaterial 时。

相反,我们要用 displacementScale 属性降低效果,把它设为 0.3:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20, 100, 100),
    new THREE.MeshStandardMaterial({
        // ...
        displacementMap: floorDisplacementTexture,
        displacementScale: 0.3,
    })
)

看起来好多了,但平面看起来还是太高。这是因为纹理不够暗,也就是说所有顶点都会上移,至少一点点。

要修复这个,我们可以用 displacementBias 属性让整个位移偏置。

我本可以直接给你正确的数值,但现在是使用调试 UI 帮你找到完美数值的好机会。

在Floor部分的末尾,给 gui 添加两个调整项,如下所示:

gui.add(floor.material, 'displacementScale').min(0).max(1).step(0.001).name('floorDisplacementScale')
gui.add(floor.material, 'displacementBias').min(-1).max(1).step(0.001).name('floorDisplacementBias')
lesson demo video preview

我们现在可以轻松测试不同的数值了。

等你对结果满意了,把两个数值填进 displacementScale 和 displacementBias:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20, 100, 100),
    new THREE.MeshStandardMaterial({
        // ...
        displacementMap: floorDisplacementTexture,
        displacementScale: 0.3,
        displacementBias: - 0.2
    })
)

我们不会每次在合理时都给调试 UI 加调整项,但对于你自己的项目,这被认为是好的实践,而且会节省你的时间。

墙壁

对于墙壁,好消息是:稍微容易一点。

首先,去 Poly Haven 选一个好看的砖墙纹理。我个人会用 Castle Brick Broken 06。

Poly Haven 应该保存了你之前的设置。确认它长这样,并取消勾选 Displacement:

点击 Download,解压文件,用压缩版的名字重命名解压后的文件夹。

在 static/ 里,创建一个 wall/ 文件夹(单数),把纹理文件夹放进去(我的情况是 static/wall/castle_brick_broken_06_1k/)。

回到 JavaScript,在Textures部分里、Floor部分之后,添加一个Wall部分,并按如下方式加载这三张贴图:

// Wall
const wallColorTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_diff_1k.jpg')
const wallARMTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_arm_1k.jpg')
const wallNormalTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_nor_gl_1k.jpg')

别忘了把 wallColorTexture 的 colorSpace 设为 THREE.SRGBColorSpace:

wallColorTexture.colorSpace = THREE.SRGBColorSpace

把全部 3 张贴图应用到 MeshStandardMaterial 的 map、aoMap、roughnessMap、metalnessMap 和 normalMap 属性上:

// Walls
const walls = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        map: wallColorTexture,
        aoMap: wallARMTexture,
        roughnessMap: wallARMTexture,
        metalnessMap: wallARMTexture,
        normalMap: wallNormalTexture
    })
)

看这个纹理,不需要调整任何东西就这么华丽。

如果你用另一张纹理,你可能需要像我们对地面纹理那样设置 repeat、wrapS 和 wrapT。

屋顶

屋顶纹理的实现和墙壁类似。

首先,去 Poly Haven 选一个纹理。你可以选瓦片、砖、芦苇、木板或者任何东西。我个人会用 Roof Slates 02。

下载设置和墙壁一样:

点击 Download,解压文件,用压缩版的名字重命名解压后的文件夹。

在 static/ 里,创建一个 roof/ 文件夹,把纹理文件夹放进去(我的情况是 static/roof/roof_slates_02_1k/)。

回到 JavaScript:在Textures部分里、Wall部分之后,添加一个Roof部分,并按如下方式加载这三张贴图:

// Roof
const roofColorTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_diff_1k.jpg')
const roofARMTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_arm_1k.jpg')
const roofNormalTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_nor_gl_1k.jpg')

roofColorTexture.colorSpace = THREE.SRGBColorSpace

把全部 3 张贴图应用到 MeshStandardMaterial 的 map、aoMap、roughnessMap、metalnessMap 和 normalMap 属性上:

const roof = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        map: roofColorTexture,
        aoMap: roofARMTexture,
        roughnessMap: roofARMTexture,
        metalnessMap: roofARMTexture,
        normalMap: roofNormalTexture
    })
)

如果你用的和我一样,你可能想在 x 轴上增加重复次数。

要这么做,把三张贴图的 repeat.x 都设为 3,并把它们的 wrapS 设为 THREE.RepeatWrapping:

roofColorTexture.repeat.set(3, 1)
roofARMTexture.repeat.set(3, 1)
roofNormalTexture.repeat.set(3, 1)

roofColorTexture.wrapS = THREE.RepeatWrapping
roofARMTexture.wrapS = THREE.RepeatWrapping
roofNormalTexture.wrapS = THREE.RepeatWrapping

除非 repeat.y 大于 1,否则不用改 wrapT。

你可能注意到了两个问题。第一个是纹理全部歪了。第二个是灯光表现有点奇怪,我们在不该有反射的地方看到了反射。

如果你移除纹理,就更容易注意到了:

lesson demo video preview

这是由 ConeGeometry 的 normals 和 uv 属性生成方式导致的,不幸的是,没有什么简单的修复办法,这就是为什么现在我们先保持原样。

不过,还是让我给你一些修复提示。

第一个选项是在 3D 软件里创建模型。举个例子,在 Blender 里创建金字塔极其简单,设置 UV 也相当简单。好消息是,你会在课程后面学会用 Blender。

第二个选项是自己创建几何体属性。这其实是个相当有趣的挑战。你可以为每个侧面创建一个三角形,为金字塔的底面创建两个三角形。然后你需要设置 UV 坐标。至于法线,好消息是,你可以用 BufferGeometry 上可用的 computeVertexNormals 方法让 Three.js 帮你计算。

但让我们继续。

灌木丛

灌木丛的纹理实现也非常相似。我跟你说过添加纹理相当费时间。

首先,去 Poly Haven 选一个纹理。我个人会用 Leaves Forest Ground。

下载设置和墙壁、屋顶一样:

点击 Download,解压文件,用压缩版的名字重命名解压后的文件夹。

在 static/ 里,创建一个 bush/ 文件夹(单数),把纹理文件夹放进去(我的情况是 static/bush/leaves_forest_ground_1k/)。

回到 JavaScript:在Textures部分里、Roof部分之后,添加一个Bush部分,用正确的 colorSpace 加载这三张贴图:

// Bush
const bushColorTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_diff_1k.jpg')
const bushARMTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_arm_1k.jpg')
const bushNormalTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_nor_gl_1k.jpg')

bushColorTexture.colorSpace = THREE.SRGBColorSpace

让我们节省时间,马上更新 repeat 和 wrapS,让纹理在 x 上重复两次:

bushColorTexture.repeat.set(2, 1)
bushARMTexture.repeat.set(2, 1)
bushNormalTexture.repeat.set(2, 1)

bushColorTexture.wrapS = THREE.RepeatWrapping
bushARMTexture.wrapS = THREE.RepeatWrapping
bushNormalTexture.wrapS = THREE.RepeatWrapping

把全部 3 张贴图应用到 bushMaterial 的 map、aoMap、roughnessMap、metalnessMap 和 normalMap 属性上:

const bushMaterial = new THREE.MeshStandardMaterial({
    map: bushColorTexture,
    aoMap: bushARMTexture,
    roughnessMap: bushARMTexture,
    metalnessMap: bushARMTexture,
    normalMap: bushNormalTexture
})

如果你仔细观察灌木丛顶部,你可能会注意到一个奇怪的拉伸,像个洞:

lesson demo video preview

和屋顶类似,自己用代码或在 3D 软件里创建几何体可能会有帮助,但有个更快的解决办法。让我们旋转灌木丛,让那个洞口大部分藏进墙壁里:

const bush1 = new THREE.Mesh(bushGeometry, bushMaterial)
// ...
bush1.rotation.x = - 0.75

const bush2 = new THREE.Mesh(bushGeometry, bushMaterial)
// ...
bush2.rotation.x = - 0.75

const bush3 = new THREE.Mesh(bushGeometry, bushMaterial)
// ...
bush3.rotation.x = - 0.75

const bush4 = new THREE.Mesh(bushGeometry, bushMaterial)
// ...
bush4.rotation.x = - 0.75

还有一件事,虽然灌木丛这样看起来很不错,但要是有点更绿的颜色就好了。显然,这取决于你选的纹理,但这正好是给你展示一个小技巧的机会。

在灌木丛的 MeshStandardMaterial 上,添加一个值为 '#ccffcc' 的 color 属性:

const bushMaterial = new THREE.MeshStandardMaterial({
    color: '#ccffcc',
    // ...
})

#ccffcc 对应一种亮绿色,这个颜色会给纹理染色。你可以对场景里任何其他材质这么做。

坟墓

现在套路你都熟了。

去 Poly Haven 选一个纹理。我个人会用 Plastered Stone Wall。

下载设置重复我们之前做的:

点击 Download,解压文件,用压缩版的名字重命名解压后的文件夹。

在 static/ 里,创建一个 grave/ 文件夹(单数),把纹理文件夹放进去(我的情况是 static/grave/plastered_stone_wall_1k/)。

回到 JavaScript:在Textures部分里、Bush部分之后,添加一个Grave部分,加载这三张贴图并按如下方式设置 repeat:

// Grave
const graveColorTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_diff_1k.jpg')
const graveARMTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_arm_1k.jpg')
const graveNormalTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_nor_gl_1k.jpg')

graveColorTexture.colorSpace = THREE.SRGBColorSpace

graveColorTexture.repeat.set(0.3, 0.4)
graveARMTexture.repeat.set(0.3, 0.4)
graveNormalTexture.repeat.set(0.3, 0.4)

当 repeat 值小于 1 时,不需要改 wrapS 和 wrapT。

把全部 3 张贴图应用到 MeshStandardMaterial 的 map、aoMap、roughnessMap、metalnessMap 和 normalMap 属性上:

const graveMaterial = new THREE.MeshStandardMaterial({
    map: graveColorTexture,
    aoMap: graveARMTexture,
    roughnessMap: graveARMTexture,
    metalnessMap: graveARMTexture,
    normalMap: graveNormalTexture
})

纹理在侧面重复得有点太多,但如果不自己创建几何体,我们也没什么办法。

门

我们快完成了。我们现在只需要处理门。

纹理已经可以在 static/door/ 文件夹里找到。这组纹理由 Katsukagi 创作,可以在这个网站 https://3dtextures.me/2019/04/16/door-wood-001/ 以 CC0 许可下载。

在Textures部分里、Graves部分之后,添加一个Door部分,并按如下方式加载所有纹理:

// Door
const doorColorTexture = textureLoader.load('./door/color.jpg')
const doorAlphaTexture = textureLoader.load('./door/alpha.jpg')
const doorAmbientOcclusionTexture = textureLoader.load('./door/ambientOcclusion.jpg')
const doorHeightTexture = textureLoader.load('./door/height.jpg')
const doorNormalTexture = textureLoader.load('./door/normal.jpg')
const doorMetalnessTexture = textureLoader.load('./door/metalness.jpg')
const doorRoughnessTexture = textureLoader.load('./door/roughness.jpg')

doorColorTexture.colorSpace = THREE.SRGBColorSpace

这次,环境光遮蔽、金属度和粗糙度纹理是分开的。还有一张 alpha 纹理。

把它们应用到 MeshStandardMaterial 上,别忘了 alphaMap,并把 transparent 设为 true:

const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2),
    new THREE.MeshStandardMaterial({
        map: doorColorTexture,
        transparent: true,
        alphaMap: doorAlphaTexture,
        aoMap: doorAmbientOcclusionTexture,
        displacementMap: doorHeightTexture,
        normalMap: doorNormalTexture,
        metalnessMap: doorMetalnessTexture,
        roughnessMap: doorRoughnessTexture
    })
)

不幸的是,即使我们加了 displacementMap,门还是全平的:

你大概已经猜到为什么了。我们 PlaneGeometry 上的顶点不够。

把 PlaneGeometry 的 widthSegments 和 heightSegments 参数设为 100:

const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2, 100, 100),
    // ...
)

起作用了,但位移太强了。

现在是给 displacementScale 和 displacementBias 加调整项的好机会,但为了节省时间,我们直接用下面的数值:

const door = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        // ...
        displacementScale: 0.15,
        displacementBias: -0.04,
        // ...
    })
)

对一扇门来说用位移有点过度,但这是一扇好看的门,而且是个玩转位移功能的机会。

灯光

多亏了这些纹理,我们的场景看起来更细致、更真实了。是时候调整布景,让它更身临其境了。

环境光和定向光

目前,AmbientLight 和 DirectionalLight 都是白色的,除非我们在实验室里,否则这不怎么真实。

把两个颜色都改成 #86cdff,把 AmbientLight 的强度设为 0.275,把 DirectionalLight 的强度设为 1:

const ambientLight = new THREE.AmbientLight('#86cdff', 0.275)
// ...

const directionalLight = new THREE.DirectionalLight('#86cdff', 1)
// ...

场景看起来很暗,但别担心,我们会解决的。

使用昏暗的 AmbientLight 可以让用户欣赏到阴影里的表面,并模拟 DirectionalLight 的光线反弹。

虽然我们不会做,但你可以随意添加一些调整项来控制灯光的颜色和强度。

门灯

像这样把 DirectionalLight 放在房子后面,会让正面(房子最重要的一面)处在阴影里。让我们通过在门上方略高处加一盏门灯来解决。

在现实生活中,门灯很可能是个灯泡,这就是为什么我们要用 PointLight。

用 PointLight 创建 doorLight 变量,把颜色设为 '#ff7d46',强度设为 5,并把它放在墙上稍远一点、正门上方的地方。最后,把它加到 house(不是 scene):

// Door light
const doorLight = new THREE.PointLight('#ff7d46', 5)
doorLight.position.set(0, 2.2, 2.5)
house.add(doorLight)

现在房子的正面抓住了每个人的注意力。灯光的暖色让它看起来像是个舒适的地方,与周围冷色环境形成了很好的对比。

鬼魂

也许你想象的是动画床单里的鬼魂,像卡通里那样挥舞着胳膊。不幸的是,在课程的这个阶段,那样做很难,我们甚至还不知道怎么导入模型。

让我们采用更极简的方式,用 PointLight 来代表鬼魂。

我们要创建三个颜色不同的 PointLight,它们会环绕房子飞行,同时以不同的频率穿进穿出地面。

在House部分之后,添加一个Ghosts部分,创建三个 PointLight,颜色和强度如下:

/**
 * Ghosts
 */
const ghost1 = new THREE.PointLight('#8800ff', 6)
const ghost2 = new THREE.PointLight('#ff0088', 6)
const ghost3 = new THREE.PointLight('#ff0000', 6)
scene.add(ghost1, ghost2, ghost3)
lesson demo video preview

目前,所有鬼魂都在房子里面。

对于动画,我们先集中处理 ghost1,之后再处理另外两个。

我们想让鬼魂绕着房子做圆周运动,这个我们已经做过好多次了。我们需要三角学。

而且因为我们希望鬼魂的位置每一帧都变化,我们要在 tick 函数里做。

正如前面三角学里提到的,当你把同一个角度同时传给正弦和余弦,你会得到圆周上一个定位点的 x 和 y 坐标:

对于角度,我们可以用 elapsedTime,它从 Timer 实例获取,对应体验开始以来经过了多少秒。

在 tick 函数里,创建一个Ghosts部分,创建一个 ghost1Angle 变量并把 elapsedTime 赋值给它:

const tick = () =>
{
    // Timer
    timer.update()
    const elapsedTime = timer.getElapsed()

    // Ghosts
    const ghost1Angle = elapsedTime

    // ...
}

我们现在可以在 ghost1 的 position.x 和 position.z 变量上,把 ghost1Angle 作为 Math.cos() 和 Math.sin() 的参数了:

const tick = () =>
{
    // ...

    // Ghosts
    const ghost1Angle = elapsedTime
    ghost1.position.x = Math.cos(ghost1Angle)
    ghost1.position.z = Math.sin(ghost1Angle)

    // ...
}
lesson demo video preview

起作用了,但使用余弦和正弦时,你得到的是半径 1 的圆上的位置。要修复这个,把两者都乘以 4:

const tick = () =>
{
    // ...

    // Ghosts
    const ghost1Angle = elapsedTime
    ghost1.position.x = Math.cos(ghost1Angle) * 4
    ghost1.position.z = Math.sin(ghost1Angle) * 4

    // ...
}
lesson demo video preview

把 elapsedTime 乘以 0.5 让它慢下来:

const tick = () =>
{
    // ...

    // Ghosts
    const ghost1Angle = elapsedTime * 0.5
    ghost1.position.x = Math.cos(ghost1Angle) * 4
    ghost1.position.z = Math.sin(ghost1Angle) * 4

    // ...
}
lesson demo video preview

我们现在想让鬼魂穿过地面上下移动。对此用正弦非常完美:

但图案太规则了。

一个简单的解决办法是组合多个频率不同的正弦。

你可以很容易地用数学公式和可视化工具(比如 Desmos Calculator)测试,找到看起来不可预测的频率。

一个正弦:

两个正弦:

三个正弦:

好的,我们现在可以把这条公式写进 JavaScript,并传给 ghost1 的 position.y:

const tick = () =>
{
    // ...

    // Ghosts
    const ghost1Angle = elapsedTime * 0.5
    ghost1.position.x = Math.cos(ghost1Angle) * 4
    ghost1.position.z = Math.sin(ghost1Angle) * 4
    ghost1.position.y = Math.sin(ghost1Angle) * Math.sin(ghost1Angle * 2.34) * Math.sin(ghost1Angle * 3.45)

    // ...
}
lesson demo video preview

现在让我们试着对 ghost2 做同样的事。

复制那 4 行代码,把 ghost1 改成 ghost2:

const tick = () =>
{
    // ...

    // Ghosts
    // ...
    
    const ghost2Angle = elapsedTime * 0.5
    ghost2.position.x = Math.cos(ghost2Angle) * 4
    ghost2.position.z = Math.sin(ghost2Angle) * 4
    ghost2.position.y = Math.sin(ghost2Angle) * Math.sin(ghost2Angle * 2.34) * Math.sin(ghost2Angle * 3.45)

    // ...
}
lesson demo video preview

它们始终在完全相同的位置。

把半径改为 5:

const tick = () =>
{
    // ...

    const ghost2Angle = elapsedTime * 0.5
    ghost2.position.x = Math.cos(ghost2Angle) * 5
    ghost2.position.z = Math.sin(ghost2Angle) * 5
    ghost2.position.y = Math.sin(ghost2Angle) * Math.sin(ghost2Angle * 2.34) * Math.sin(ghost2Angle * 3.45)

    // ...
}
lesson demo video preview

在 elapsedTime 前面加一个 - 来反转方向:

const tick = () =>
{
    // ...

    const ghost2Angle = - elapsedTime * 0.5

    // ...
}
lesson demo video preview

把 elapsedTime 乘以 0.38 而不是 0.5,来改变整个动画的频率:

const tick = () =>
{
    // ...

    const ghost2Angle = - elapsedTime * 0.38

    // ...
}
lesson demo video preview

最后,是我们的最后一个 ghost3。现在是自己尝试完成这个过程的好机会。

把频率乘数设为 0.23,不要用 - 反转它,半径设为 6:

const tick = () =>
{
    // ...

    const ghost3Angle = elapsedTime * 0.23
    ghost3.position.x = Math.cos(ghost3Angle) * 6
    ghost3.position.z = Math.sin(ghost3Angle) * 6
    ghost3.position.y = Math.sin(ghost3Angle) * Math.sin(ghost3Angle * 2.34) * Math.sin(ghost3Angle * 3.45)

    // ...
}
lesson demo video preview

阴影

为了增加更多真实感,我们要激活阴影。

就像我们在阴影课程看到的,让多个灯光投射阴影并不总是好看,但因为这个场景相当复杂,我们可以让鬼魂和定向光投射阴影,同时仍得到不错的结果。

在我们的代码里,我们要把阴影设置集中在同一个地方,而不是散布得到处都是。

在Renderer部分之后,创建一个Shadows部分:

/**
 * Shadows
 */

Renderer

在那里,首先在 renderer 上激活阴影贴图:

// Renderer
renderer.shadowMap.enabled = true

我们还要把 type 改成 THREE.PCFSoftShadowMap,以获得更真实的结果:

renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap

投射和接收阴影

接下来,通过把 castShadow 设为 true,在 directionalLight 和三个鬼魂上激活阴影:

// Cast and receive
directionalLight.castShadow = true
ghost1.castShadow = true
ghost2.castShadow = true
ghost3.castShadow = true

门灯差别不大,所以我们别让它投射阴影,以提升性能。

我们现在需要遍历每个物体,决定它是否需要投射阴影和/或接收阴影(先忽略坟墓):

walls.castShadow = true
walls.receiveShadow = true
roof.castShadow = true
floor.receiveShadow = true

我们已经在欣赏房子的阴影了。

坟墓稍微麻烦一点,因为它们在 for() 循环里创建,意味着我们无法在循环外访问它们的变量。

一个解决办法是直接在 for() 循环里加 castShadow 和 receiveShadow,那样也能行,但我们希望所有与阴影相关的代码在同一个地方。

万幸,我们把所有坟墓都放进了 graves Group,而且这个 group 在 for() 循环外是能访问的。

我们可以用 for(… of …) 遍历 graves.children 属性,更新每个子物体:

for(const grave of graves.children)
{
    grave.castShadow = true
    grave.receiveShadow = true
}
lesson demo video preview

遍历这种数组还有其他方式,但这个方法就挺好用。

映射

场景有了这些阴影看起来好多了,但别忘了优化它们,确保阴影贴图能很好地贴合场景。

一个不错的做法是遍历每个灯光,在 light.shadowMap.camera 上创建一个相机 helper,然后调整相机直到完美。但那会很无聊,因此我已经替你做完了,这样你就可以直接使用下面的数值。

首先,对于 directionalLight:

// Mappings
directionalLight.shadow.mapSize.width = 256
directionalLight.shadow.mapSize.height = 256
directionalLight.shadow.camera.top = 8
directionalLight.shadow.camera.right = 8
directionalLight.shadow.camera.bottom = - 8
directionalLight.shadow.camera.left = - 8
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 20

当场景贴图丰富、细节满满时,低分辨率的阴影贴图会让它看起来更弥散,而不会出现可见伪影。反过来,这对性能有利,也增加了场景的视觉吸引力。

对于鬼魂灯光,我们可以把 mapSize 降到 256,把 far 设为 10:

ghost1.shadow.mapSize.width = 256
ghost1.shadow.mapSize.height = 256
ghost1.shadow.camera.far = 10

ghost2.shadow.mapSize.width = 256
ghost2.shadow.mapSize.height = 256
ghost2.shadow.camera.far = 10

ghost3.shadow.mapSize.width = 256
ghost3.shadow.mapSize.height = 256
ghost3.shadow.camera.far = 10
lesson demo video preview

天空

场景本身看起来很棒,但黑色背景很逊。让我们玩点大的,实现 Sky 类,你可以在这里看到它施展魔法:https://threejs.org/examples/?q=sky#webgl_shaders_sky

这是一个参数众多、很逼真的天空。因为我们要在后面的课程里更深地探索 Sky 的实现,也因为这节课已经相当长了,我们要直接实现它,不做任何调整。不过,如果你想深入这个类,可以看看这个示例的代码:https://github.com/mrdoob/three.js/blob/master/examples/webgl_shaders_sky.html

首先,从 three/addons/objects/Sky.js 导入 Sky:

import { Sky } from 'three/addons/objects/Sky.js'

在Shadow部分之后,实例化 Sky,把它赋值给 sky 变量并加到 scene:

/**
 * Sky
 */
const sky = new Sky()
scene.add(sky)

如果你现在就测试结果,你会什么也看不到。

首先,我们需要给这个 Sky 实例加一些参数,而方式相当奇怪。我们要更新 sky 的 material 上的一些 uniforms,如下所示(别忘了 .value):

sky.material.uniforms['turbidity'].value = 10
sky.material.uniforms['rayleigh'].value = 3
sky.material.uniforms['mieCoefficient'].value = 0.1
sky.material.uniforms['mieDirectionalG'].value = 0.95
sky.material.uniforms['sunPosition'].value.set(0.3, -0.038, -0.95)

更新材质的方式好奇怪,对吧?

这就是我们在 Three.js 里更新自定义 shader 参数的方式。别担心,你会在Shaders章节学到所有需要的东西。

所有这些值都是精心挑选来让它好看的,但通常你需要加一些调整项来找到完美的设置。

但天空到底在哪里?

它就在那里,只是太小了:

lesson demo video preview

它看起来像个小立方体。

既然 Sky 继承自 Mesh,我们可以更新它的 scale 属性:

const sky = new Sky()
sky.scale.set(100, 100, 100)
scene.add(sky)
lesson demo video preview

雾

在恐怖电影里,他们总是用雾让场景更有压迫感。你不想让幸存者太早看到出口。好消息是,Three.js 通过 Fog 和 FogExp2 支持这个功能。

两者的区别在于密度的计算方式。让我们从 Fog 开始。

在Sky部分之后,添加一个Fog部分,按如下方式实例化 Fog 并把 fog.scene 赋值给它:

/**
 * Fog
 */
scene.fog = new THREE.Fog('#ff0000', 1, 13)
lesson demo video preview

第一个参数是 color,我们用了纯红色,让我们能注意到雾在哪里。待会我们会替换它。

第二个参数是 near(雾从离相机多远处开始),第三个参数是 far(雾在离相机多远处会完全不透明)。

如果你想完全控制雾从哪里开始、到哪里结束,Fog 很有用。

但还有 FogExp2。

注释掉之前 Fog,用下面的参数试试 FogExp2:

// scene.fog = new THREE.Fog('#ff0000', 1, 13)
scene.fog = new THREE.FogExp2('#ff0000', 0.1)
lesson demo video preview

这个更直白:离相机越远,密度越高。

第一个参数是 color,第二个参数是 density,这其实是一种更真实的雾的做法。

我们会坚持用这个,但颜色怎么办?你能做的最好的事,是选一个能和背景(这里指天空)融为一体的颜色。

用取色器取天空底部的颜色,把它应用到雾上:

// scene.fog = new THREE.Fog('#04343f', 1, 13)
scene.fog = new THREE.FogExp2('#04343f', 0.1)
lesson demo video preview

纹理优化

我们完成了……但等等!我们忘了一件相当重要的事。我们下载并使用了逼真的纹理,但你检查过它们有多重吗?

lesson demo video preview

这些图片大约重 13MB,对这样一个简单的场景来说实在太多了。

纹理太大、太重,这不只是加载的问题。对 GPU 也是问题。纹理越大,占用的内存就越多。这对性能不利,而且纹理上传到 GPU 时还会产生小幅卡顿。

我们要让纹理尽可能小,同时还要压缩它们。目前,我们有十来张 JPG 文件,这已经是个不错的开端了。但现在有一种更好的格式,叫 WEBP。

WEBP 现在被所有主流浏览器支持,只有旧版 Safari 和 Safari iOS 不支持:https://caniuse.com/webp

WEBP 支持透明。

WEBP 采用有损压缩,意味着图像会变差。目标是找到压缩和文件大小之间的平衡点,这个平衡点通常围绕在 80%。

虽然我们不会用到,但也有无损版本。

记住,你可能不想对用作数据的纹理(比如法线贴图)使用有损压缩。但对于这样一个磨损感的场景,你可以安全地这么做,哪怕是对法线贴图。否则,你可能想坚持用无损格式,比如 PNG,甚至 WEBP 的无损版本(因为是的,也有一个)。

至于转换图片的工具,周围有很多,你可能已经有自己喜欢的那款了。

这里是一个热门的在线解决方案小清单:

还有很多本地方案、NPM 库、扩展等等:

这节课,我们要用 CloudConvert,但你可以自由测试其他方案。

再说一次,如果你赶时间,可以在资源文件里用视频播放器下方的Resources按钮找到 WEBP 版本。

CloudConvert 每天只限几次转换,但如果你创建账户,可以达到每天 25 次压缩。别忘了,你是把图片发送到服务器上。尽量别往那里发敏感数据。

打开压缩页面:https://cloudconvert.com/jpg-to-webp

把灌木丛纹理文件发给它:

点击扳手图标,把 Width 和 Height 设为 256,把 Quality 设为 80:

在点击Okay之前,按按钮右边的小箭头,选择Apply options to all open files:

点击Convert按钮,等待压缩完成:

现在你可以下载这些文件,并把它们放到和 JPG 相同的文件夹里。我建议你在确认全部完成之前保留原始文件。你甚至可能想把原始文件保存在别处,以防万一。

在 JavaScript 中加载灌木丛纹理的地方,把 .jpg 替换成 .webp:

const bushColorTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_diff_1k.webp')
const bushARMTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_arm_1k.webp')
const bushNormalTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_nor_gl_1k.webp')

注意,如果你用了不同的纹理,路径可能会不一样。

纹理没有以前那么锐利了,但小了很多。

让我们对每张纹理重复这个过程:

完成后,看一看纹理的体积:

lesson demo video preview

我们降到了仅仅 1.6MB,大约是原来的八分之一。你的用户和他们的 GPU 会感谢你的。

理想情况下,我们会从 Poly Haven 下载 PNG 纹理,这样我们就有未经过任何有损压缩的原始文件。然后再把它转换成 WEBP。别担心,JPG 的质量本来就已经很高了。

WEBP 不是唯一的方案,还有很多其他格式。Basis 就是其中之一。它是一种对 GPU 友好的纹理格式,但实现相当复杂,而且有缺点,所以我们这里不讨论它。

继续深入

这节课到这里就完成了。如你所见,我们可以用 Three.js 开箱即用的东西做很多事。是的,好的纹理有很大帮助。显然,如果我们实现了自定义模型,效果会更好,但那要留到后面的课程。

不过,如果你想继续这个项目,还有提升空间: